
特洛伊戰爭打完之後,其他人差不多都回家鄉了,奧德修斯卻開始了一趟超級漫長的返鄉之旅,在海上繞了好多年,就是怎樣都回不了家。
後來,他準備離開女巫喀耳刻住的島時,喀耳刻先提醒他:
「前面有一條很麻煩的海峽,兩邊各有一種完全不同的危險。」
一邊住著怪物斯庫拉,她躲在岩壁上的洞穴裡,長了六條長長的脖子,每條脖子上都有一顆頭,每顆頭裡有三排牙齒,只要有船從附近經過,她就會六顆頭一起伸出來,一口一個,直接從甲板上抓走六名水手。
另一邊是卡律布狄斯,一個巨大到足以把整艘船吞掉的海怪,她每天會把海水吸進去三次,再吐出來三次,吸水的時候,海面會整個往下陷,甚至能看到底下的海床,船要是靠得太近,基本上是整艘船直接消失。
這件事情奧德修斯並沒有跟船員們講,因為奧德修斯早就下定決心要走哪一邊了....
當船經過海峽時,船員們全都盯著另一邊那個瘋狂翻騰的漩渦,深怕下一秒整艘船就被吸進去,結果就在大家忙著看卡律布狄斯的時候,斯庫拉從岩洞裡伸出六顆頭,一下子就抓走了六個人。
最後,船成功通過海峽。
Monster Method 指的是一個 method 膨脹到塞進太多步驟、規則與例外流程,導致我們很難單獨理解其中一塊,更不敢確定修改一個地方會不會傷到另一段,問題不只是行數很多,而是資料流、控制流程與不同職責開始糾纏在一起。
就像奧德修斯遇到的怪獸一樣,都不是好惹的,而我們除了要有膽識之外,也需要先辨識它們的型態後,再處理它們,今天我們來看看最常見的兩種 Monster Method。
第一種叫 Bulleted Method,它的外觀不醜,可以看到它查資料、計算、判斷、寫入,一個區塊接著一個區塊往下排,把畫面拉遠一點看,就像一份條列清單或是腳本。
今日範例 中有一座停車場的計費系統 ParkingFeeService,結帳 method 長這樣:
public decimal CalculateParkingFee(string ticketId, DateTime exitTime)
{
var ticket = _ticketRepo.Get(ticketId);
var lot = _lotRepo.Get(ticket.LotId);
// 計時以 30 分鐘為單位無條件進位,刷卡機精度只到半小時
var units = Math.Ceiling((exitTime - ticket.EntryTime).TotalMinutes / 30.0);
decimal baseFee = lot.UnitRate * (decimal)units;
// 週末全天加收 80 元平台費,不管停幾小時,物業合約條款
decimal platformFee = 0m;
if (ticket.EntryTime.DayOfWeek == DayOfWeek.Saturday
|| ticket.EntryTime.DayOfWeek == DayOfWeek.Sunday)
platformFee = 80m;
// 夜間加成兩成:22:00 到次日 07:00,補夜班人力成本
decimal nightSurcharge = 0m;
if (ticket.EntryTime.Hour >= 22 || ticket.EntryTime.Hour < 7)
nightSurcharge = baseFee * 0.2m;
// 月租會員前三小時免費,超出才計費,合約條款不得折回
var member = _memberRepo.FindByPlate(ticket.LicensePlate);
decimal memberDiscount = 0m;
if (member != null && member.HasMonthlyPass)
{
var freeUnits = Math.Min(units, 6); // 6 units = 3 小時
memberDiscount = lot.UnitRate * (decimal)freeUnits;
}
// 管理費只抽基本停車費,不含加成和平台費,當年跟物業談的規則
decimal managementFee = baseFee * 0.05m;
// ...後面還有活動日加成、開發票、寫結帳紀錄,每段又是二三十行
return baseFee + platformFee + nightSurcharge + managementFee - memberDiscount;
}
運氣好的話,前人還會像這樣留註解或空行幫我們分段,表面上每一段都很完整,很容易讓人產生幻覺,順著整個程式碼的資料流重新看一次,就會發現:
| 資料 | 在哪裡產生 | 後面誰還需要它 |
|---|---|---|
units |
進場與離場時間換算 | 基本費、月租折扣 |
baseFee |
單價乘上計費單位 | 夜間加成、管理費、最後總額 |
ticket.EntryTime |
停車票資料 | 計時、週末平台費、夜間加成 |
ticket.LicensePlate |
停車票資料 | 查詢月租會員 |
假設明天真的把「夜間加成」的部分抽出去,新的 method 就必須接收進場時間與 baseFee,如果抽「月租折扣」,則會需要單價、units、車牌和會員資料存取,如果只是照著程式碼無腦硬切,新的 method 很快就會需要一長串參數,這不一定代表不能抽取,而是提醒我們畫面上的區塊,不一定就是好的拆分邊界。
所以遇到條列式程式碼的閱讀重點是:
每一個區塊需要哪些既有資料,又會產生哪些資料給後面使用?
第二種叫 Snarled Method,它的主要問題不是步驟排得太長,而是 if、foreach、switch 等控制流程彼此巢狀,一個判斷包住下一個判斷。
控制流程 是程式決定「下一步要執行哪一段」的路線,每一個 if 或 case 都會把路線分成不同 branch(分支),當分支裡還有分支,就會形成巢狀結構,讀者必須同時記住外面每一層條件,才知道目前這幾行在什麼情況下會執行。
今日範例 中一段停車場月租車位申請流程 ParkingMonthlyService:
public MonthlyPassResult ApplyMonthlyPass(MonthlyPassRequest request)
{
var result = new MonthlyPassResult();
if (request.StartDate >= DateTime.Today)
{
var slotsA = _slotRepo.GetAvailable(Zone.A, request.StartDate);
if (slotsA.Count == 0)
{
// A 區額滿就走升等路線:掃 B 區看有沒有剩餘車位
foreach (var slot in _slotRepo.GetAvailable(Zone.B, request.StartDate))
{
if (slot.AllowsUpgrade)
{
switch (request.ApplicantType)
{
case ApplicantType.Corporate:
var quota = _quotaRepo.GetCorporateRemaining(request.CompanyId);
if (quota > 0)
{
if (IsPeakSeason(request.StartDate))
{
// 旺季升等 B 區先收一個月訂金,管委會規定
result.RequireDeposit = true;
result.DepositAmount = slot.MonthlyRate;
}
result.SlotId = slot.Id;
result.Status = MonthlyPassStatus.Held;
}
else
{
result.Status = MonthlyPassStatus.WaitList;
}
break;
case ApplicantType.Residential:
// ...住戶升等條件另有四十行,巢得跟上面一樣深
break;
}
}
if (result.Status == MonthlyPassStatus.Held) break;
}
}
else
{
// ...A 區還有位置的正常路線,又是一層一層的優先序判斷
}
}
return result;
}
要讀到旺季訂金的部分,就得依序經過申請人送出申請開始,之後先查 A 區有沒有空位,A 區滿了就轉到 B 區走升等路線,升等路線又依申請人類型分成法人戶與住戶,法人戶還要查公司剩餘配額,旺季則要先收一個月訂金:
日期有效
└── A 區額滿
└── B 區車位允許升等
└── 申請人是法人戶
└── 法人配額有剩
└── 旺季才收訂金
只要其中一層不成立,程式就不會走到訂金規則,而每一層旁邊可能還有自己的 else、break 或另一套後續處理,所以糾纏式的閱讀重點通常會是:
我想處理的那一行,需要先通過哪些 branch 才到得了?
也就是閱讀方向可能得要由內向外閱讀。
Legacy Code 這麼複雜,有沒有工具可以先幫我們把可能的問題先大概抓出來?
這時候就可以請 SonarQube 這個工具來幫忙,它會對程式碼進行靜態分析,在不執行程式的情況下,依照規則找出可靠性、安全性與可維護性等問題,我平常也會很常使用到它,好東西就要跟好朋友分享,至於要怎麼使用它呢,我們繼續看下去。
首先我們需要透過 Docker 在本機啟動 SonarQube,如果還不熟悉容器技術,或電腦尚未安裝 Docker Desktop 的讀者嗎,可以先閱讀我另外整理的 Docker 基本與安裝 文章喔 > <
補充教材整理了 Container 的用途、Windows 與 macOS 安裝流程,以及安裝後的基本驗證,這裡就不贅述了,準備好 Docker 後我們就可以繼續往下了
確認 Docker Engine 已經啟動後,在 Docker Desktop 中搜尋 sonarqube:

按下 Pull 將官方鏡像抓取下來,Host port 設定 9000 就好了,接著按下 Run

等到容器啟動後,就可以用瀏覽器打開 http://localhost:9000,預設帳號與密碼都會是 admin

登入後系統會要求更換密碼。
進入 SonarQube 主頁後按下 Create a local project

這一步是在 SonarQube 裡先建立一個接收分析結果的專案,還沒有開始掃描。
Project display name 是畫面上顯示的名稱,Project key 則是 SonarQube 辨識專案的唯一代號,等等執行 Scanner 時必須和這裡完全一致,Main branch name 填專案實際使用的主要分支,範例是 master,如果我們的 Repository 用的是 main,這裡就改成 main。

確認三個欄位後按下 Next,SonarQube 就知道稍後收到的分析資料應該放到哪個專案底下。
Scanner 最後要把分析結果送回 SonarQube,因此需要一個 Token 證明自己有權限執行。
右上角點選頭像到 My Account 頁面

進入 Security 分頁

在 Generate Tokens 區域,輸入資訊後,點 Generate

這裡選擇 Project Analysis Token,讓權限只落在 Day21-22,產製之後,Token 只會在產生後顯示一次,複製起來保管好,我們等等就會用到。
SonarQube 服務負責接收分析結果與顯示報告,真正跟著收集程式碼分析資料的則是 SonarScanner for .NET,先透過以下指令安裝它:
dotnet tool install --global dotnet-sonarscanner
我們首先要先透過 begin 指令先連上 SonarQube,取得專案目前套用的 Quality Profile 與分析設定,再把 Scanner 接進接下來的 .NET build。
dotnet sonarscanner begin \
/k:"Day21-22" \
/d:sonar.token="請換成自己的Token" \
/d:sonar.host.url="http://localhost:9000"
三個參數分別表示
分析結果要送到哪個 Project Key
用什麼憑證驗證
SonarQube 服務位址在哪裡
執行後如果看到 Pre-processing succeeded. 代表前置設定已經完成,Scanner 正在等接下來的編譯資料,還不是整次分析結束喔。
dotnet build --no-incremental
接著執行 build,讓 Scanner 在實際編譯過程中收集專案結構與 C# 分析結果。
dotnet sonarscanner end /d:sonar.token="請換成自己的Token"
最後的 end 會結束這次分析、清除 build 階段掛上的分析設定,收集剛才產生的結果並上傳到 SonarQube。
完成分析後,我們前往 SonarQube 的 Day21-22 中,左側欄可以看到一個 Issue 頁面

這次一共找到了三個 Issue,其中第一個是 High,另外兩個則是把 IsPeakSeason() 改成 static 的 Low,以及命名規則的 Info,這三條是目前 Quality Profile 啟用的規則所提出的檢查結果。
我們先點開第一個 High 來看看。

畫面會直接帶到 ParkingMonthlyService.cs 的程式碼,並標出這個 method 裡讓複雜度增加的 if、foreach 與 switch,點開 How can I fix it 頁面,還可以看到它給我們的建議,雖然就當作參考就是了 XD

是不是超酷的啊!
不過實際上出現 Issue 的評斷,還會受到目前使用的 Quality Profile、啟用規則與門檻影響,所以會依照實務上團隊的開發標準來決定品質門檻。
透過工具可以幫助我們發現不對勁的地方,這真的超級方便,但真正判斷它該從何下手?該不該動手?業務的語言是什麼?這些仍然需要人去理解。
SonarQube 的功能當然也不如此,功能超級多的,實務上我們也很常把它放進產線中,讓每次部署都能再做程式碼品質的各方位掃描,各位有興趣的話可以慢慢逛喔 :)
在 Legacy Code 中,遇到的 Monster Method 很少是純種,往往同時可以看到好幾種特徵。
野生的通常都是混種
常見情況是外層看起來像條列式,一段一段往下排,但某個 method 裡是糾纏式,也可能反過來,在糾纏式的某個深層 branch 裡,埋著一堆條列流程。
聽著感覺挺可怕的,不過沒關係,今天我們先了解到這些 method ,也順便知道了 SonarQube 這個好東西後,接下來我們將學習如何改善它們。
明天我們繼續看:如何一步一步解開條列與糾纏